This page last changed on May 15, 2009 by aunger.

There are a few gotcha's associated with using Polymorphic associations when you're namescoping your ActiveRecord models.

How it normally works

Here's a simple set of model definitions:

Unable to find source-code formatter for language: ruby. Available languages are: actionscript, html, java, javascript, none, sql, xhtml, xml
class Activity < ActiveRecord::Base
  has_many :reports, :as => :reportable
end

class Model < ActiveRecord::Base
  has_many :reports, :as => :reportable
end

class Report < ActiveRecord::Base
  belongs_to :reportable, :polymorphic => true
end

This is the normal way of doing a polymorphic association. We can do things like:

Unable to find source-code formatter for language: ruby. Available languages are: actionscript, html, java, javascript, none, sql, xhtml, xml
report = Report.find(:first)
activity = Activity.find(:first)
activity.reports << report
report.reportable   # returns activity

It works because there are two columns in the reports table: reportable_id and reportable_type. reportable_id holds the id of either an Activity or a Model. Reportable_type hold the String representation of the Class of the Model or Activity associated with that report (ie "Model" or "Activity").

When activity.reports is called, ActiveRecord basically makes this SQL request (note the use of base_class instead of class):

SELECT * FROM reports WHERE reportable_id = #{activity.id} AND reportable_type = '#{activity.base_class.name}'

When report.reportable is called, the code basically does this:

Unable to find source-code formatter for language: ruby. Available languages are: actionscript, html, java, javascript, none, sql, xhtml, xml
reportable_type.constantize.find(reportable_id)  # returns a model or activity

constantize is a way of getting a Class object from the string representation of that Class object's name.

Namescoped models

Now let's imagine a different model for naming the classes – putting the model classes inside a module in order to separate them logically from other models

Unable to find source-code formatter for language: ruby. Available languages are: actionscript, html, java, javascript, none, sql, xhtml, xml
class Name::Base < ActiveRecord::Base
end

class Name::Activity < Name::Base
  has_many :reports, :as => :reportable
end

class Name::Model < Name::Base
  has_many :reports, :as => :reportable
end

class Name::Report < Name::Base
  belongs_to :reportable, :polymorphic => true
end

There are a couple immediate problems with this approach:

  • Activity.reports and Model.reports will be looking for the Report class instead of the Name::Report class. This can be fixed by adding the :class_name parameter to the has_many definition:
    Unable to find source-code formatter for language: ruby. Available languages are: actionscript, html, java, javascript, none, sql, xhtml, xml
    class Name::Activity < Name::Base
      has_many :reports, :as => :reportable, :class_name => "Name::Report"
    end
    
    class Name::Model < Name::Base
      has_many :reports, :as => :reportable, :class_name => "Name::Report"
    end
    
  • Activity.reports ends up looking for the Base Class instead of the object class. This means that it will be looking for reports with a reportable_type of Name::Base instead of Name::Model or Name::Activity. This can be fixed with a monkey-patch (see below).

So now we have (models and monkey-patch):

Unable to find source-code formatter for language: ruby. Available languages are: actionscript, html, java, javascript, none, sql, xhtml, xml
class Name::Base < ActiveRecord::Base
end

class Name::Activity < Name::Base
  has_many :reports, :as => :reportable, :class_name => "Name::Report"
end

class Name::Model < Name::Base
  has_many :reports, :as => :reportable, :class_name => "Name::Report"
end

class Name::Report < Name::Base
  belongs_to :reportable, :polymorphic => true
end

#monkey patch below
module ActiveRecord
  module Associations
    class HasManyAssociation
      def construct_sql
        case
          when @reflection.options[:finder_sql]
            @finder_sql = interpolate_sql(@reflection.options[:finder_sql])

          when @reflection.options[:as]
            @finder_sql = 
              "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_id = #{owner_quoted_id} AND " +
              "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = #{@owner.class.quote_value(@owner.class.name.to_s)}"
            @finder_sql << " AND (#{conditions})" if conditions
          
          else
            @finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = #{owner_quoted_id}"
            @finder_sql << " AND (#{conditions})" if conditions
        end

        if @reflection.options[:counter_sql]
          @counter_sql = interpolate_sql(@reflection.options[:counter_sql])
        elsif @reflection.options[:finder_sql]
          # replace the SELECT clause with COUNT(*), preserving any hints within /* ... */
          @reflection.options[:counter_sql] = @reflection.options[:finder_sql].sub(/SELECT (\/\*.*?\*\/ )?(.*)\bFROM\b/im) { "SELECT #{$1}COUNT(*) FROM" }
          @counter_sql = interpolate_sql(@reflection.options[:counter_sql])
        else
          @counter_sql = @finder_sql
        end
      end
    end
  end
end

The important line in the monkey-patch is:

Unable to find source-code formatter for language: ruby. Available languages are: actionscript, html, java, javascript, none, sql, xhtml, xml
"#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = #{@owner.class.quote_value(@owner.class.name.to_s)}"

which in the original code looks like:

Unable to find source-code formatter for language: ruby. Available languages are: actionscript, html, java, javascript, none, sql, xhtml, xml
"#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = #{@owner.class.quote_value(@owner.class.base_class.name.to_s)}"

A further twist

Here at CC, we've been using namescoped classes in one application to access non-namescoped classes in another application in a read-only manner (as it's way faster than ActiveResource). This throws a couple more wrenches in the works.

You now have to be able to convert from Model and Activity to Name::Model and Name::Activity. Unfortunately, ActiveRecord won't automatically try to find classes within the current namescope, so we have to add some more monkey-patching to do it for us.

In order to make the code flexible, I added a new attribute to the has_many association – :unscoped => boolean – and one to the belongs_to association – :namescope => "Name::Scope::String". The :namescope string gets prepended to the class name which gets pulled out of the reportable_type database column, and if :unscoped is specified, all of the module info gets stripped from the class name when searching for reports that belong to a Model or Activity.

The final code and monkey-patch looks like this:

Unable to find source-code formatter for language: ruby. Available languages are: actionscript, html, java, javascript, none, sql, xhtml, xml
class Name::Base < ActiveRecord::Base
end

class Name::Activity < Name::Base
  has_many :reports, :as => :reportable, :class_name => "Name::Report", :unscoped => true
end

class Name::Model < Name::Base
  has_many :reports, :as => :reportable, :class_name => "Name::Report", :unscoped => true
end

class Name::Report < Name::Base
  belongs_to :reportable, :polymorphic => true, :namescope => "Name"
end


# monkey patch
module ActiveRecord
  module Associations
    class BelongsToPolymorphicAssociation
      
      def association_class
        scope = @reflection.options[:namescope] ? (@reflection.options[:namescope] + "::") : ""
        @owner[@reflection.options[:foreign_type]] ? (scope + @owner[@reflection.options[:foreign_type]]).constantize : nil
      end
    end
    
    class HasManyAssociation
      def construct_sql
        case
          when @reflection.options[:finder_sql]
            @finder_sql = interpolate_sql(@reflection.options[:finder_sql])

          when @reflection.options[:as]
            class_name = @reflection.options[:unscoped] ? @owner.class.name.to_s.split("::").last : @owner.class.base_class.name.to_s
            @finder_sql = 
              "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_id = #{owner_quoted_id} AND " +
              "#{@reflection.quoted_table_name}.#{@reflection.options[:as]}_type = #{@owner.class.quote_value(class_name)}"
            @finder_sql << " AND (#{conditions})" if conditions
          
          else
            @finder_sql = "#{@reflection.quoted_table_name}.#{@reflection.primary_key_name} = #{owner_quoted_id}"
            @finder_sql << " AND (#{conditions})" if conditions
        end

        if @reflection.options[:counter_sql]
          @counter_sql = interpolate_sql(@reflection.options[:counter_sql])
        elsif @reflection.options[:finder_sql]
          # replace the SELECT clause with COUNT(*), preserving any hints within /* ... */
          @reflection.options[:counter_sql] = @reflection.options[:finder_sql].sub(/SELECT (\/\*.*?\*\/ )?(.*)\bFROM\b/im) { "SELECT #{$1}COUNT(*) FROM" }
          @counter_sql = interpolate_sql(@reflection.options[:counter_sql])
        else
          @counter_sql = @finder_sql
        end
      end
    end
    
    module ClassMethods
      @@valid_keys_for_belongs_to_association = [
                :class_name, :foreign_key, :foreign_type, :remote, :select, :conditions,
                :include, :dependent, :counter_cache, :extend, :polymorphic, :readonly,
                :validate, :namescope
              ]
      @@valid_keys_for_has_many_association = [
                :class_name, :table_name, :foreign_key, :primary_key,
                :dependent,
                :select, :conditions, :include, :order, :group, :having, :limit, :offset,
                :as, :through, :source, :source_type,
                :uniq,
                :finder_sql, :counter_sql,
                :before_add, :after_add, :before_remove, :after_remove,
                :extend, :readonly,
                :validate,
                :unscoped
              ]
    end
  end
end
Document generated by Confluence on Jan 27, 2014 16:56